Go back home
Making a blogging system with Phoenix and React [Part 4]

Making a blogging system with Phoenix and React [Part 4]

making-a-blogging-system-with-phoenix-and-react-[part-4]

Making use of our API

Hello and welcome to part 4 ! This is the last part so congratulations if you have made it this far !

In our previous post, we added the ability to write blog posts in our Phoenix back-office.
In this post, we're scaffolding our API and displaying it all in the front-end (React in my case).

What we are aiming to do is :

  • Fetch all our published posts in the front-end

  • Display them in a list to the user

  • Fetch one post

  • Display it nicely formatted to the user

Slugify the title

I know I said we were done with the back-end setup. I lied.
There are a few more things we need to do in order to ensure a more user-friendly experience.

As of right now, to fetch our posts in the database we use the Phoenix default function that depends on the post's ID. Depending on how you setup your database, it could be 123 if you used integer IDs or it could be 287632fb-018b-448f-a9e2-d953702dc759 if you use UUIDs.

However, I want to make sure of two things:

  1. That our post can easily be found via URL (no one wants to type example.com/posts/28763fb-018b...)

  2. That our post title will always be unique.

We are, after all, the sole authors of this blog, there's no reason why two posts would have the exact same title.

Note that if you are planning to write extremely long post titles, you are better off not following this part but instead fetching through the ID

To do that, we are going to slugify our post title and add a get_post_by_slug!/1 function to our app.

We start by adding the slug column to our posts with mix ecto.gen.migration add_slug_to_posts. This will add a file in app/priv/repo/migrations. Open this file and alter the contents to match:

defmodule App.Repo.Migrations.AddSlugToPosts do
  use Ecto.Migration

  def change do
+   alter table(:posts) do
+     add :slug, :string
+   end
  end
end

Then we'll update our schema in app/lib/app/blog/post.ex, create our slugify_title/2 function as well as modify our changeset to make sure that our slug is unique:

defmodule App.Blog.Post do
  use Ecto.Schema
  import Ecto.Changeset
  use Waffle.Ecto.Schema

  schema "posts" do
    field :title, :string
    field :content, :string
    field :published_at, :naive_datetime
    field :tags, :string
    field :feature_image, App.FeatureImage.Type
+   field :slug, :string

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(post, attrs) do
    post
    |> cast(attrs, [:title, :content, :published_at, :tags])
+   |> validate_required([:title, :content, :published_at, :tags, :slug])
+   |> unique_constraint(:slug)
  end

  #If the title is part of our changeset, this function will match...
  def slugify_title(post, %{"title" => title}) when is_binary(title) do
      # /!\ I did NOT come up with this recipe but I failed to write down the source. If this is your bit of code, let me know so I can add the proper credits
      slug = title
      |> String.normalize(:nfc)
      |> String.downcase()
      |> String.trim()
      |> String.replace([" ",",",";"], "-")
      |> String.replace(~r/-{2,}/, "-") # If you don't speak regex, this means "replace any -- by a -"
      |> String.trim("-") # and here we remove any extra trailing - (beginning or end)

      post
      |> put_change(:slug, slug)
  end
  #...otherwise we do nothing with the slug
  def slugify_title(post, %{})  do
      post
  end

Note: Using slugs is good for SEO, however if the title (and the slug) came to change, this would break a link and impact your score. Ideally you'd still fetch based on a unique, immutable ID-like value that can be parsed from the URL. Lucky for you, Hashrocket has that exact tutorial.

Now we are ready to migrate with the command mix ecto.migrate (do that) and implement our last two functions (I promise). We're opening our app/lib/app/blog.ex file and adding the functions get_post_by_slug!/1 and list_published_posts/0. We need this last one to prevent any technically unpublished posts from leaking to the front-end (remember that we have a published_at field in our posts). We also make sure to show the latest posts first with order_by: [desc: p.published_at].

# app/lib/app/blog.ex

  @doc"""
  Returns the list of published posts
  """
  def list_published_posts do
    now = NaiveDateTime.local_now()
    query = from(p in Post, where: p.published_at < ^now, order_by: [desc: p.published_at])
    Repo.all(query)
  end

  @doc"""
  Get a post by slug
  """
  def get_post_by_slug!(slug) do
    Repo.get_by!(Post, slug: slug)
  end

  #...rest of file

Okay now we're done with adding functions, we can scaffold away !

Scaffolding the API routes

Generate the controller and JSON

Once again, Phoenix makes this easy for us. We'll just have to repeat our post schema so that the proper JSON is generated :

mix phx.gen.json --no-schema --no-context Blog Post posts title:string content:string published_at:naive_datetime tags:string feature_image:string slug:string --web Api

Notice the last parameter "--web Api". This will generate a subdirectory in our app/lib/app_web/controllers/ called "Api" where our JSON and controller will live. This makes it easier to manage both types of resources.

Now let's add our routes to the router.ex file. We only need two, under the :api scope:

#app/lib/app_web/router.ex
   scope "/api", App do
     pipe_through :api
      get "/posts", Api.PostController,  :index
      get "/posts/:slug", Api.PostController, :show
   end

Make use of our functions

Let's head into our app/lib/app_web/controllers/api/post_controller file and use the two functions we created just before:

#app/lib/app_web/controllers/api/post_controller.ex
#...beginning of file
    def index(conn, _params) do
        posts = Blog.list_published_posts() # Make this modification
        render(conn, :index, posts: posts)
    end

    def show(conn, %{"slug" => slug}) do
        post = Blog.get_post_by_slug!(slug) # Make this modification
        render(conn, :show, post: post)
    end
#...rest of file

Note: We don't use any of the create/update/delete functions in the API, feel free to delete those.

Nice ! Now we should be able to list our published posts by making a GET request to localhost:4000/api/posts and to retrieve a single post by making another GET request to localhost:4000/api/posts/my-post-title.

> http localhost:4000/api/posts
>>> HTTP/1.1 200 OK
>>> cache-control: max-age=0, private, must-revalidate
>>> content-length: 1568
>>> content-type: application/json; charset=utf-8
>>> date: Fri, 18 Oct 2024 00:34:07 GMT
>>> server: Cowboy
>>> x-request-id: F_9k1SDj20sQ8m8AAAmI
>>>   {
>>>       "data": [
>>>           {
>>>               "content": "<p>My content</p>",
>>>               "feature_image": {
>>>                   "original": "https://{mybucket}/uploads/6_original_post_image.jpg?v=63895580273",
>>>                   "thumb": "https://{mybucket}/uploads/6_thumb_post_image.jpg?v=63895580273"
>>>               },
>>>               "id": 6,
>>>               "published_at": "2024-10-01T08:00:00",
>>>               "slug": "my-other-post",
>>>               "tags": "tag,tag,tag",
>>>               "title": "My other post"
>>>           },
>>>           {
>>>               "content": "<p>Test content </p>",
>>>               "feature_image": {
>>>                   "original": "https://{mybucket}/uploads/5_original_post_image.jpg?v=63895580273",
>>>                   "thumb": "https://{mybucket}/uploads/5_thumb_post_image.jpg?v=63895580273"
>>>               },
>>>               "id": 5,
>>>               "published_at": "2024-10-02T06:00:00",
>>>               "slug": "my-new-post",
>>>               "tags": "tag,tag,tag",
>>>               "title": "My new post"
>>>           },
>>>           ...
>>>       ]
>>>   }




> http localhost:4000/api/posts/my-new-post
>>>   HTTP/1.1 200 OK
>>>   cache-control: max-age=0, private, must-revalidate
>>>   content-length: 377
>>>   content-type: application/json; charset=utf-8
>>>   date: Fri, 18 Oct 2024 00:37:43 GMT
>>>   server: Cowboy
>>>   x-request-id: F_9lB0uLucaAbwAAAABB
>>>
>>>   {
>>>       "data": {
>>>           "content": "<p>Test content beep</p>",
>>>           "feature_image": {
>>>               "original": "https://{mybucket}/uploads/5_original_post_image.jpg?v=63895580273",
>>>               "thumb": "https://{mybucket}/uploads/5_thumb_post_image.jpg?v=63895580273"
>>>           },
>>>           "id": 5,
>>>           "published_at": "2024-10-02T06:00:00",
>>>           "slug": "my-new-post",
>>>           "tags": "tag,tag,tag",
>>>           "title": "My new post"
>>>       }
>>>   }

Onto the frontend

I default to React out of bad habit but at this point this is just some AJAX and rendering which you can implement however you want (how about a little bit of Svelte or maybe some Astro ?).

Listing the posts

I will spare you the details of setting up a React app. There has to be millions of tutorials on that by now.

Just know that these are the packages I use and that you might need to install or find an alternative to :

axios
dompurify
hightlight.js
react-router
react-router-dom

We create the Blog.jsx component. It will make the call to our back-end to retrieve all posts and display them in a loop.

import { useEffect, useState } from "react";
import axios from "axios";
import { Link } from "react-router-dom";
const Blog = () => {
  const [data, setData] = useState([]);
  const [loading, setLoading] = useState(false);
  async function fetchData() {
    setLoading(true);
    try {
      const res = await axios.get("http://localhost:4000/api/posts"); // For the sake of this tutorial, I'm not taking my usual shortcut of creating an axios instance with a base url here
      setData(res.data.data);
      setLoading(false);
    } catch (error: any) {
      setLoading(false);
      console.trace(error);
      // Do something with the error
    }
  }
  useEffect(() => {
    fetchData();
  }, []);
  return (
    <section id="blog">
    <h1>Blog posts<h1>
      {loading && <div>Loading...</div>}
      <div className="posts">
        {data.map((post) => {
          return (
            <article key={post.id}>
              <img src={post.feature_image.thumb} />
              <div>
                <h4>{post.title}</h4>
                <Link to={"/post/" + post.slug}> {/*Our frontend link to the post*/}
                  <button>Read more</button>
                </Link>
              </div>
            </article>
          );
        })}
      </div>
    </section>
  );
};
export default Blog;

I will go ahead and use this component in my Main.jsx component that groups all the different parts of my app.

Displaying a post

Then another component, Post.tsx to display the actual post:

import { useParams } from "react-router";
import DOMPurify from "dompurify";
import hljs from "highlight.js/lib/core";
// My theme of choice for the code blocks
import "highlight.js/styles/base16/zenburn.min.css";
// Here I list the languages I mostly use to keep the bundle size down
import javascript from "highlight.js/lib/languages/javascript";
import elixir from "highlight.js/lib/languages/elixir";
import css from "highlight.js/lib/languages/css";
import { useEffect, useState } from "react";

const Post = () => {
  const [data, setData] = useState({});
  const [loading, setLoading] = useState(false);

  const {slug} = useParams()

  async function fetchData() {
    setLoading(true);
    try {
      const res = await axios.get("http://localhost:4000/api/posts/" + slug); // For the sake of this tutorial, I'm not taking my usual shortcut of creating an axios instance with a base url here
      setData(res.data.data);
      setLoading(false);
    } catch (error: any) {
      setLoading(false);
      console.trace(error);
      // Do something with the error
    }
  }
  useEffect(() => {
    fetchData();
  }, []);
  // Registers our selected languages in hljs for highlighting
  hljs.registerLanguage("javascript", javascript);
  hljs.registerLanguage("elixir", elixir);
  hljs.registerLanguage("css", css);
  useEffect(() => {
    hljs.highlightAll();
  }, []);

  if(loading) {
      return (<div>Loading post...</div>)
  }
  return (
    <article id="post">
      <img src={post.feature_image.original} />
      <header>
        <h2>{post.title}</h2>
        <sup>{post.slug}</sup>
      </header>
      {/* We might be the authors of the HTML but it's good practice to treat setting raw HTML as dangerous.Here we use DOMPurify to sanitize it */}
      <div
        dangerouslySetInnerHTML={{
          __html: DOMPurify.sanitize(post.content),
        }}
        id="content"
      />
    </article>
  );
};
export default Post;

Finally, adding these two components to our routing.

//App.jsx or wherever your routes are
    <Routes>
      <Route
        path="/post/:slug"
        element={
            <Post />
        }
      />
      <Route path="*" element={<Main />} /> {/*Remember that Blog.jsx lives in Main.jsx*/}
    </Routes>
  );



All you have to do now is create some styling for your blog posts and we're done ! We can now:

  • Create a blog post in our Phoenix back-office

  • Attach an image to it

  • Query for posts on our React front-end

  • Display said post under a user-friendly slugified URL

Hope you enjoyed :)

Troubleshooting

CORS Errors

If you are stuck behind a CORS error or a 302 response from Phoenix, try installing the CORS plug and setting it up to allow your origin.